package edu.kufpg.armatus.console;
import java.util.Collection;
import java.util.Locale;
import java.util.SortedSet;
import java.util.Stack;
import java.util.TreeSet;
import com.google.common.collect.SortedSetMultimap;
import com.google.common.collect.TreeMultimap;
import edu.kufpg.armatus.util.ParcelUtils;
import android.os.Parcel;
import android.os.Parcelable;
/**
* Searches a console's entries for words that match a given criterion. Because this
* class can potentially store a lot of data, it is recommended that if you need to
* destroy a {@code ConsoleSearcher}, you should parcel it and reload it later
* instead of recreating a new instance every time.
*/
public class ConsoleWordSearcher implements Parcelable {
/** Value indicating that no matching indexes were found. */
private static final int NO_MATCH = -1;
/** Reference to the console's adapter (used for indicating when the adapter should
* update the selection highlight). */
private ConsoleEntryAdapter mAdapter;
/** The current search criterion. */
private String mCriterion;
/** Maps entry contents (in all lowercase) to their matching search offsets. */
private SortedSetMultimap<String, Integer> mSearchOffsetsMap = TreeMultimap.create();
/** Stacks the previously highlighted matches (from bottom to top in ascending
* console order). */
private Stack<MatchParams> mPreviousMatches = new Stack<MatchParams>();
/** Stacks the matches to be highlighted next after {@link #mSelectedMatch} (from
* top to bottom in ascending console order). */
private Stack<MatchParams> mNextMatches = new Stack<MatchParams>();
/** The currently selected highlighted match. */
private MatchParams mSelectedMatch;
/** Tracks the number of matches for {@link #mCriterion}. */
private int mMatchCount = 0;
/**
* Constructs a new instance with a reference to the specified adapter.
* @param adapter The {@link ConsoleEntryAdapter1} to reference.
*/
public ConsoleWordSearcher(ConsoleEntryAdapter adapter) {
attachAdapter(adapter);
}
/**
* Restores the reference to the console's adapter, which can be destroyed after
* device standby or rotation.
* @param adapter The {@link ConsoleEntryAdapter1} to reconnect to.
*/
void attachAdapter(ConsoleEntryAdapter adapter) {
mAdapter = adapter;
mAdapter.attachSearcher(this);
}
/**
* Begin a new search with a specified search criterion.
* @param criterion The string to search for.
* @return The {@link MatchParams} of the first match, or {@code null} if there
* are no matches.
*/
public synchronized MatchParams beginSearch(String criterion) {
mCriterion = criterion.toLowerCase(Locale.US);
mMatchCount = 0;
mSearchOffsetsMap.clear();
mPreviousMatches.clear();
mNextMatches.clear();
mSelectedMatch = null;
if (!mCriterion.isEmpty()) {
for (int groupIndex = 0; groupIndex < mAdapter.getGroupCount(); groupIndex++) {
for (int childIndex = 0; childIndex < mAdapter.getChildrenCount(groupIndex); childIndex++) {
String entryContents = mAdapter.getChild(groupIndex, childIndex).toString().toLowerCase(Locale.US);
SortedSet<Integer> offsets = mSearchOffsetsMap.get(entryContents);
if (offsets.isEmpty() && !mSearchOffsetsMap.containsEntry(entryContents, NO_MATCH)) {
offsets = getMatchIndexes(mCriterion, entryContents);
}
for (int offset : offsets) {
if (offset != NO_MATCH) {
//Add them to beginning of stack to avoid having to reverse order later
mNextMatches.add(0, new MatchParams(groupIndex, childIndex, offset));
mMatchCount++;
mSearchOffsetsMap.put(entryContents, offset);
}
}
}
}
if (mMatchCount > 0) {
mSelectedMatch = mNextMatches.pop();
}
}
mAdapter.notifyDataSetChanged();
return mSelectedMatch;
}
/**
* Resume the ongoing search in the specified {@link SearchDirection}.
* @param direction Either {@link SearchDirection#NEXT} or {@link SearchDirection#PREVIOUS}.
* @return The {@link MatchParams} of the newly selected match, or {@code null}
* if there are no matches.
*/
public synchronized MatchParams continueSearch(SearchDirection direction) {
MatchParams curSelection = null;
if (mMatchCount > 0) {
Stack<MatchParams> popper = null, pusher = null;
switch (direction) {
case NEXT:
popper = mNextMatches;
pusher = mPreviousMatches;
break;
case PREVIOUS:
popper = mPreviousMatches;
pusher = mNextMatches;
}
if (!popper.empty()) {
pusher.push(mSelectedMatch);
mSelectedMatch = popper.pop();
} else {
while (!pusher.empty()) {
popper.push(mSelectedMatch);
mSelectedMatch = pusher.pop();
}
}
curSelection = mSelectedMatch;
}
mAdapter.notifyDataSetChanged();
return curSelection;
}
/**
* Ends the current search, removing any highlighting.
*/
public synchronized void endSearch() {
mCriterion = null;
mMatchCount = 0;
mSearchOffsetsMap.clear();
mPreviousMatches.clear();
mNextMatches.clear();
mSelectedMatch = null;
mAdapter.notifyDataSetChanged();
}
/**
* Returns the current search criterion.
* @return the current search criterion or {@code null} if there is no
* ongoing search.
*/
public synchronized String getCriterion() {
return mCriterion;
}
/**
* Returns the number of matches for the current search criterion.
* @return the number of current matches. If there is no ongoing search, 0 is
* returned.
*/
public synchronized int getMatchesCount() {
return mMatchCount;
}
/**
* Returns a sorted set of {@link android.widget.TextView TextView} offsets for the
* specified string's matches to the current search criterion.
* @param contents The string whose matches should be returned. The string must match
* the contents of a current {@link ConsoleEntry} (ignoring case) or this method will
* not return the correct result.
* @return a {@link SortedSet} of the string matches' {@code TextView} offsets.
* If there are no matches, {@code null} is returned.
*/
public synchronized SortedSet<Integer> getMatchOffsets(String contents) {
if (hasMatches(contents)) {
return mSearchOffsetsMap.get(contents.toLowerCase(Locale.US));
} else {
return null;
}
}
/**
* Returns a sorted set of string starting indexes (inclusive) where {@code pattern}
* is located in {@code target}.
* @param pattern The string to search for in {@code target}.
* @param target The string in which {@code pattern} matches are searched for.
* @return A {@link SortedSet} of indexes indicating matches. If there are no
* matches, the set will only contain {@link #NO_MATCH}.
*/
private static SortedSet<Integer> getMatchIndexes(String pattern, String target) {
SortedSet<Integer> matches = new TreeSet<Integer>();
if (!pattern.isEmpty()) {
for (int i = target.indexOf(pattern); i >= 0; i = target.indexOf(pattern, i+1)) {
matches.add(i);
}
if (matches.isEmpty()) {
matches.add(NO_MATCH);
}
} else {
matches.add(NO_MATCH);
}
return matches;
}
/**
* Return the parameters of the selected match.
* @return the currently selected {@link MatchParams} or {@code null} if there
* is either no ongoing search or no search matches.
*/
public synchronized MatchParams getSelectedMatch() {
return mSelectedMatch;
}
/**
* Returns the position of the selected match in relation to all other matches.
* The first position is 1.
* @return the position of the currently selected match or {@link #NO_MATCH} if
* there is either no ongoing search or no search matches.
*/
public synchronized int getSelectedMatchPosition() {
if (isSearching() && mMatchCount > 0) {
return mPreviousMatches.size() + 1;
} else {
return NO_MATCH;
}
}
/**
* Determines if the specified string contains any matches with the current search
* criterion.
* @param contents The string to check for matches. The string must match the
* contents of a current {@link ConsoleEntry} (ignoring case) or this method will not
* return the correct result.
* @return if the string contains at least one match with the current search criterion.
*/
public synchronized boolean hasMatches(String contents) {
if (contents == null) {
return false;
}
Collection<Integer> offsets = mSearchOffsetsMap.get(contents.toLowerCase(Locale.US));
return !offsets.contains(NO_MATCH) && !offsets.isEmpty();
}
/**
* Returns if this {@link ConsoleWordSearcher} is currently searching.
* @return if a search is ongoing.
*/
public synchronized boolean isSearching() {
return mCriterion != null;
}
public static final Parcelable.Creator<ConsoleWordSearcher> CREATOR
= new Parcelable.Creator<ConsoleWordSearcher>() {
@Override
public ConsoleWordSearcher createFromParcel(Parcel in) {
return new ConsoleWordSearcher(in);
}
@Override
public ConsoleWordSearcher[] newArray(int size) {
return new ConsoleWordSearcher[size];
}
};
private ConsoleWordSearcher(Parcel in) {
mCriterion = in.readString();
mSearchOffsetsMap = ParcelUtils.readTreeMultimap(in);
in.readTypedList(mPreviousMatches, MatchParams.CREATOR);
in.readTypedList(mNextMatches, MatchParams.CREATOR);
mSelectedMatch = in.readParcelable(ConsoleWordSearcher.class.getClassLoader());
mMatchCount = in.readInt();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(mCriterion);
ParcelUtils.writeMultimap(dest, mSearchOffsetsMap);
dest.writeTypedList(mPreviousMatches);
dest.writeTypedList(mNextMatches);
dest.writeParcelable(mSelectedMatch, flags);
dest.writeInt(mMatchCount);
}
/** The parameters of a search match index. */
public static class MatchParams implements Parcelable {
public final int entryIndex;
public final int lineIndex;
public final int textViewOffset;
public MatchParams(int groupIndex, int childIndex, int textViewOffset) {
this.entryIndex = groupIndex;
this.lineIndex = childIndex;
this.textViewOffset = textViewOffset;
}
public MatchParams(MatchParams params) {
entryIndex = params.entryIndex;
lineIndex = params.lineIndex;
textViewOffset = params.textViewOffset;
}
public static Parcelable.Creator<MatchParams> CREATOR =
new Parcelable.Creator<MatchParams>() {
@Override
public MatchParams createFromParcel(Parcel source) {
int groupIndex = source.readInt();
int childIndex = source.readInt();
int textViewOffset = source.readInt();
return new MatchParams(groupIndex, childIndex, textViewOffset);
}
@Override
public MatchParams[] newArray(int size) {
return new MatchParams[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(entryIndex);
dest.writeInt(lineIndex);
dest.writeInt(textViewOffset);
}
}
/** The possible ways to look up the next match. */
public enum SearchDirection {
/** Search for the next-highest {@link android.widget.TextView TextView} offset.
* If there is none, search for the first offset in the next-highest {@link
* android.widget.ListView ListView} index. If there is none, wrap around to the
* very first match. */
NEXT,
/** Search for the next-lowest {@link android.widget.TextView TextView} offset.
* If there is none, search for the last offset in the next-lowest {@link
* android.widget.ListView ListView} index. If there is none, wrap around to the
* very last match. */
PREVIOUS }
}